#!/usr/bin/env python

# This Python file uses the following encoding: utf-8
import sys
import socket
import random
from PySide6.QtWidgets import (
    QApplication, QMainWindow, QWidget, QLabel, QLineEdit, QTextEdit, QPushButton, QVBoxLayout, QHBoxLayout, QTabWidget, QCheckBox, QSplitter, QFileDialog, QSizePolicy, QGroupBox, QGridLayout, QComboBox
)
from PySide6.QtCore import QTimer, Signal, Qt
from PySide6.QtGui import QPixmap, QIcon
# from PySide6.QtGui import QTextCursor
import threading
import datetime
import re
import time
import os
import traceback

# 简单的异常捕获器
def simple_exception_handler(exc_type, exc_value, exc_traceback):
    """捕获未处理的异常并写入日志"""
    try:
        # 生成错误信息
        error_msg = f"程序崩溃时间: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
        error_msg += f"错误类型: {exc_type.__name__}\n"
        error_msg += f"错误信息: {exc_value}\n"
        
        # 写入错误日志
        log_dir = os.path.join(os.getcwd(), 'logs')
        if not os.path.exists(log_dir):
            os.makedirs(log_dir)
        
        error_log_file = os.path.join(log_dir, 'error_log.txt')
        with open(error_log_file, 'a', encoding='utf-8') as f:
            f.write(error_msg + "\n")
            
    except:
        pass
    
    # 调用原始异常处理器
    sys.__excepthook__(exc_type, exc_value, exc_traceback)

# 设置异常处理器
sys.excepthook = simple_exception_handler

class TcpWidget(QWidget):
    startExposureTimer = Signal(int)
    log_signal = Signal(str)
    first_recv_signal = Signal(str)
    append_recv_signal = Signal(str)

    def __init__(self, udp_widget=None):
        super().__init__()
        self.udp_widget = udp_widget
        ip_label = QLabel("目标IP地址:")
        self.ip_input = QLineEdit('192.168.0.233')
        self.ip_input.setFixedWidth(140)
        port_label = QLabel("目标端口号:")
        self.port_input = QLineEdit('11005')
        self.port_input.setFixedWidth(90)
        local_port_label = QLabel("本地端口:")
        local_port_label.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
        self.local_port_input = QLineEdit()
        self.local_port_input.setFixedWidth(90)
        self.local_port_input.setText("8080")

        # 移除连接/断开按钮，改为状态显示
        self.connection_status = QLabel("未连接")
        self.connection_status.setFixedWidth(120)
        self.connection_status.setStyleSheet("color: red;")

        # 顶部设置区两行
        top_row1 = QHBoxLayout()
        top_row1.addWidget(ip_label)
        top_row1.addWidget(self.ip_input)
        top_row1.addWidget(port_label)
        top_row1.addWidget(self.port_input)
        top_row1.addStretch(1)

        top_row2 = QHBoxLayout()
        top_row2.addWidget(local_port_label)
        top_row2.addWidget(self.local_port_input)
        top_row2.addWidget(self.connection_status)
        top_row2.addStretch(1)

        # 曝光指令模块
        exposure_group = QWidget()
        exposure_layout = QVBoxLayout()
        exposure_label = QLabel("曝光指令:")
        self.exposure_send_input = QTextEdit()
        # self.exposure_send_input.setMinimumWidth(350)  # 删除或注释
        self.exposure_send_input.setLineWrapMode(QTextEdit.WidgetWidth)
        self.exposure_send_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
        # 读出时间输入框
        readout_time_layout = QHBoxLayout()
        self.readout_time_label = QLabel("读出时间(s):")
        self.readout_time_input = QLineEdit()
        self.readout_time_input.setText("10")
        self.readout_time_input.setFixedWidth(60)
        readout_time_layout.addWidget(self.readout_time_label)
        readout_time_layout.addWidget(self.readout_time_input)
        # 发送曝光指令按钮
        self.exposure_send_button = QPushButton("发送曝光指令")
        self.exposure_send_button.setMinimumHeight(32)
        self.exposure_send_button.clicked.connect(self.send_exposure_message)
        # 批量曝光相关控件
        self.batch_file_button = QPushButton("选择批量文件")
        self.batch_file_button.setMinimumHeight(32)
        self.batch_file_button.clicked.connect(self.select_batch_file)
        self.batch_file_path = QLineEdit()
        self.batch_file_path.setMinimumHeight(32)
        self.batch_file_path.setReadOnly(True)
        
        # 超时重拍功能控件
        timeout_retry_layout = QHBoxLayout()
        self.timeout_retry_checkbox = QCheckBox("启用超时重拍")
        self.timeout_retry_checkbox.setChecked(False)
        timeout_retry_layout.addWidget(self.timeout_retry_checkbox)
        
        # 添加超时等待时间设置
        timeout_wait_label = QLabel("超时等待时间(s):")
        self.timeout_wait_input = QLineEdit()
        self.timeout_wait_input.setText("20")
        self.timeout_wait_input.setFixedWidth(60)
        timeout_retry_layout.addWidget(timeout_wait_label)
        timeout_retry_layout.addWidget(self.timeout_wait_input)
        
        # 添加超时重拍次数显示
        retry_count_label = QLabel("重拍次数:")
        self.retry_count_display = QLineEdit()
        self.retry_count_display.setText("0")
        self.retry_count_display.setFixedWidth(60)
        self.retry_count_display.setReadOnly(True)
        timeout_retry_layout.addWidget(retry_count_label)
        timeout_retry_layout.addWidget(self.retry_count_display)
        
        timeout_retry_layout.addStretch(1)
        
        self.batch_start_button = QPushButton("批量曝光")
        self.batch_start_button.setMinimumHeight(32)
        self.batch_start_button.clicked.connect(self.start_batch_exposure)
        self.batch_start_button.setEnabled(False)
        
        # 添加中断序列按钮
        self.interrupt_button = QPushButton("中断序列")
        self.interrupt_button.setMinimumHeight(32)
        self.interrupt_button.clicked.connect(self.interrupt_batch_exposure)
        self.interrupt_button.setEnabled(False)
        
        batch_btn_layout = QHBoxLayout()
        batch_btn_layout.addWidget(self.batch_file_button)
        batch_btn_layout.addWidget(self.batch_start_button)
        batch_btn_layout.addWidget(self.interrupt_button)
        batch_path_layout = QHBoxLayout()
        batch_path_layout.addWidget(self.batch_file_path)
        exposure_layout.addWidget(exposure_label)
        exposure_layout.addWidget(self.exposure_send_input)
        exposure_layout.addLayout(readout_time_layout)
        exposure_layout.addWidget(self.exposure_send_button)
        exposure_layout.addLayout(timeout_retry_layout)
        exposure_layout.addLayout(batch_btn_layout)
        exposure_layout.addLayout(batch_path_layout)
        exposure_group.setLayout(exposure_layout)

        # 其他指令模块
        normal_group = QWidget()
        normal_layout = QVBoxLayout()
        normal_label = QLabel("其他指令:")
        self.normal_send_input = QTextEdit()
        # self.normal_send_input.setMinimumWidth(350)  # 删除或注释
        self.normal_send_input.setLineWrapMode(QTextEdit.WidgetWidth)
        self.normal_send_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
        self.normal_send_button = QPushButton("发送指令")
        self.normal_send_button.setMinimumHeight(32)
        self.normal_send_button.clicked.connect(self.send_normal_message)
        normal_layout.addWidget(normal_label)
        normal_layout.addWidget(self.normal_send_input)
        normal_layout.addWidget(self.normal_send_button)
        normal_layout.addSpacing(self.exposure_send_button.sizeHint().height() + batch_btn_layout.sizeHint().height() + batch_path_layout.sizeHint().height())
        normal_group.setLayout(normal_layout)

        # 指令发送区域左右布局
        send_split_layout = QHBoxLayout()
        send_split_layout.addWidget(exposure_group, 1)
        send_split_layout.addWidget(normal_group, 1)

        # 返回信息框
        recv_label = QLabel("返回信息:")
        self.recv_output = QTextEdit()
        self.recv_output.setReadOnly(True)
        # 全局清除返回信息按钮
        self.clear_button = QPushButton("清除返回信息")
        self.clear_button.setMinimumHeight(32)
        self.clear_button.clicked.connect(self.clear_recv_output)

        # 移除持续连接相关变量，改为每次建立新连接
        self.exposure_state = "idle"
        self.last_exposure_command = None
        # 批量状态
        self.batch_commands = []
        self.batch_index = 0
        self.batch_running = False
        self.waiting_first_response = False
        # 超时重拍相关变量
        self.current_exposure_start_time = None
        # 超时重拍计数器
        self.timeout_retry_count = 0
        self.current_exposure_timeout = None
        self.abort_socket = None
        self.first_recv_signal.connect(self.recv_output.setPlainText)
        self.append_recv_signal.connect(self.recv_output.append)

        # 日志保存文件夹控件
        log_dir_label = QLabel("日志保存文件夹:")
        self.log_dir_input = QLineEdit()
        self.log_dir_input.setReadOnly(True)
        self.log_dir_button = QPushButton("选择文件夹")
        self.log_dir_button.clicked.connect(self.select_log_dir)
        log_dir_layout = QHBoxLayout()
        log_dir_layout.addWidget(log_dir_label)
        log_dir_layout.addWidget(self.log_dir_input)
        log_dir_layout.addWidget(self.log_dir_button)

        # layout
        main_layout = QVBoxLayout()
        main_layout.addLayout(top_row1)
        main_layout.addLayout(top_row2)
        main_layout.addLayout(log_dir_layout)
        main_layout.addLayout(send_split_layout)
        main_layout.addWidget(recv_label)
        main_layout.addWidget(self.recv_output)
        main_layout.addWidget(self.clear_button)
        self.setLayout(main_layout)
        # 设置窗口初始更宽
        self.setMinimumWidth(800)
        self.setMinimumHeight(600)
        self.log_file_path = None
        self._log_file_error = False
        self._log_file_timestamp = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
        # 默认日志目录
        default_log_dir = os.path.join(os.getcwd(), 'logs')
        if not os.path.exists(default_log_dir):
            os.makedirs(default_log_dir)
        self.log_dir_input.setText(default_log_dir)
        log_filename = f"{self._log_file_timestamp}.log"
        self.log_file_path = os.path.join(default_log_dir, log_filename)
        self.append_recv_signal.emit(f"日志将保存到: {self.log_file_path}")

    def create_connection(self):
        """建立新的TCP连接"""
        ip = self.ip_input.text().strip()
        port_text = self.port_input.text().strip()
        local_port_text = self.local_port_input.text().strip()
        if not ip or not port_text or not local_port_text:
            self.append_recv_signal.emit("请填写完整的目标IP、端口和本地端口！")
            return None
        try:
            port = int(port_text)
            local_port = int(local_port_text)
        except ValueError:
            self.append_recv_signal.emit("端口号必须为整数！")
            return None
        try:
            tcp_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            tcp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            tcp_socket.bind(("0.0.0.0", local_port))
            tcp_socket.connect((ip, port))
            self.connection_status.setText("已连接")
            self.connection_status.setStyleSheet("color: green;")
            return tcp_socket
        except Exception as e:
            self.append_recv_signal.emit(f"连接失败: {e}")
            self.connection_status.setText("连接失败")
            self.connection_status.setStyleSheet("color: red;")
            return None

    def close_connection(self, tcp_socket):
        """关闭TCP连接"""
        if tcp_socket:
            try:
                tcp_socket.shutdown(socket.SHUT_RDWR)
            except:
                pass
            try:
                tcp_socket.close()
            except:
                pass
        self.connection_status.setText("未连接")
        self.connection_status.setStyleSheet("color: red;")

    def send_and_wait_response(self, message, is_exposure=False):
        """发送消息并等待响应，收到完成信号后断开连接"""
        # 创建后台线程执行网络通信
        thread = threading.Thread(
            target=self._send_and_wait_response_thread,
            args=(message, is_exposure),
            daemon=True
        )
        thread.start()
        return True  # 立即返回，不等待线程完成

    def _send_and_wait_response_thread(self, message, is_exposure=False):
        """后台线程执行网络通信"""
        tcp_socket = self.create_connection()
        if not tcp_socket:
            return False
        
        try:
            # 发送消息
            tcp_socket.sendall(message.encode())
            self.append_recv_signal.emit(f"发送指令: {message}")
            self.log(f"发送指令: {message}")
            
            # 如果是曝光指令且启用了超时重拍，设置超时监控
            if is_exposure and self.timeout_retry_checkbox.isChecked():
                exposure_time = self.parse_exposure_time(message)
                # 从界面读取超时等待时间
                try:
                    timeout_wait = float(self.timeout_wait_input.text().strip())
                except ValueError:
                    timeout_wait = 20.0  # 默认值
                timeout_seconds = exposure_time + timeout_wait  # 曝光时间 + 用户设置的超时等待时间
                self.current_exposure_start_time = time.time()
                self.current_exposure_timeout = timeout_seconds
                self.append_recv_signal.emit(f"设置超时监控：{timeout_seconds}秒（曝光时间{exposure_time}秒 + 超时等待{timeout_wait}秒）")
                self.log(f"设置超时监控：{timeout_seconds}秒（曝光时间{exposure_time}秒 + 超时等待{timeout_wait}秒）")
            
            # 等待响应
            had_timeout = False
            while True:
                # 检查是否超时
                if (is_exposure and self.timeout_retry_checkbox.isChecked() and 
                    self.current_exposure_start_time and self.current_exposure_timeout):
                    elapsed_time = time.time() - self.current_exposure_start_time
                    if elapsed_time > self.current_exposure_timeout:
                        self.append_recv_signal.emit(f"曝光超时！已等待{elapsed_time:.1f}秒，超过{self.current_exposure_timeout}秒")
                        self.log(f"曝光超时！已等待{elapsed_time:.1f}秒，超过{self.current_exposure_timeout}秒")
                        
                        # 发送ABORT指令
                        self.append_recv_signal.emit("发送ABORT指令...")
                        self.log("发送ABORT指令...")
                        abort_success = self.send_abort_command()
                        
                        if abort_success:
                            self.append_recv_signal.emit("ABORT指令发送成功，等待2秒后重试...")
                            self.log("ABORT指令发送成功，等待2秒后重试...")
                        else:
                            self.append_recv_signal.emit("ABORT指令发送失败，仍将重试...")
                            self.log("ABORT指令发送失败，仍将重试...")
                        time.sleep(2)
                        # 标记超时并交由上层批量逻辑进行重试
                        had_timeout = True
                        return False
                
                # 设置socket超时，以便能够检查超时状态
                tcp_socket.settimeout(1.0)
                try:
                    data = tcp_socket.recv(4096)
                    if not data:
                        break
                    msg = data.decode(errors='ignore')
                    self.append_recv_signal.emit(f"收到: {msg}")
                    self.log(f"收到: {msg}")
                    
                    # 如果是曝光指令，检查完成信号
                    if is_exposure and "<ACTION=DONE>" in msg:
                        self.append_recv_signal.emit("曝光完成，断开连接")
                        self.log("收到完成信号：<ACTION=DONE>")
                        self.log("曝光完成，断开连接")
                        # 清除超时监控状态
                        self.current_exposure_start_time = None
                        self.current_exposure_timeout = None
                        break
                    # 如果是普通指令，收到第一个响应就断开
                    elif not is_exposure:
                        break
                except socket.timeout:
                    # 超时继续循环，检查是否超过曝光超时时间
                    continue
                    
        except Exception as e:
            self.append_recv_signal.emit(f"通信异常: {e}")
            self.log(f"通信异常: {e}")
        finally:
            self.close_connection(tcp_socket)
            # 清除超时监控状态（仅当不是因超时而返回时）
            if is_exposure and not 'had_timeout' in locals() or (is_exposure and 'had_timeout' in locals() and not had_timeout):
                self.current_exposure_start_time = None
                self.current_exposure_timeout = None
        
        return True

    def send_exposure_message(self):
        message = self.exposure_send_input.toPlainText()
        if not message:
            self.append_recv_signal.emit("请输入曝光指令内容！")
            return
        self.recv_output.clear()
        self.send_and_wait_response(message, is_exposure=True)

    def send_normal_message(self):
        message = self.normal_send_input.toPlainText()
        if not message:
            self.append_recv_signal.emit("请输入普通指令内容！")
            return
        self.recv_output.clear()
        self.send_and_wait_response(message, is_exposure=False)

    def send_read_image_command(self):
        # 只负责自动发送UDP包，不做UI端口校验
        if not self.udp_widget:
            self.append_recv_signal.emit("未配置UDP通信端口，无法发送读出指令！")
            self.log("未配置UDP通信端口，无法发送读出指令！")
            return
        ip = self.udp_widget.ip_input.text().strip()
        port_text = self.udp_widget.port_input.text().strip()
        try:
            port = int(port_text)
        except Exception:
            self.append_recv_signal.emit("UDP端口号无效！")
            self.log("UDP端口号无效！")
            return
        message_to_send = "get_frame_290:done=01"
        try:
            with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
                s.sendto(message_to_send.encode(), (ip, port))
            self.append_recv_signal.emit(f"已通过UDP发送读出图像指令: {message_to_send} 到 {ip}:{port}")
            self.log(f"已通过UDP发送读出图像指令: {message_to_send} 到 {ip}:{port}")
        except Exception as e:
            self.append_recv_signal.emit(f"UDP发送失败: {e}")
            self.log(f"UDP发送失败: {e}")

    def clear_recv_output(self):
        self.recv_output.clear()
        self.exposure_state = "idle"

    def closeEvent(self, event):
        event.accept()

    def parse_exposure_time(self, command):
        """从指令中解析曝光时间（秒）"""
        try:
            # 查找曝光时间参数，常见的格式如 EXPOSURE_TIME=1.5 或 exposure=2.0
            import re
            patterns = [
                r'EXPST_TIME=(\d+\.?\d*)'
            ]
            
            for pattern in patterns:
                match = re.search(pattern, command, re.IGNORECASE)
                if match:
                    return float(match.group(1))
            
            # 如果没有找到明确的曝光时间，返回默认值
            return 10.0
        except:
            return 10.0

    def send_abort_command(self):
        """发送ABORT指令"""
        try:
            ip = self.ip_input.text().strip()
            port_text = self.port_input.text().strip()
            if not ip or not port_text:
                self.append_recv_signal.emit("无法发送ABORT指令：IP或端口未配置")
                return False
            
            port = int(port_text)
            abort_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            abort_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            abort_socket.settimeout(5)  # 5秒超时
            
            # 尝试连接到目标
            abort_socket.connect((ip, port))
            
            # 发送ABORT指令
            abort_message = "<ID=303><CamID=25078>"
            abort_socket.sendall(abort_message.encode())
            self.append_recv_signal.emit(f"发送ABORT指令: {abort_message}")
            self.log(f"发送ABORT指令: {abort_message}")
            
            # 等待响应
            try:
                data = abort_socket.recv(4096)
                if data:
                    response = data.decode(errors='ignore')
                    self.append_recv_signal.emit(f"ABORT响应: {response}")
                    self.log(f"ABORT响应: {response}")
            except socket.timeout:
                self.append_recv_signal.emit("ABORT指令发送成功，但未收到响应")
                self.log("ABORT指令发送成功，但未收到响应")
            
            abort_socket.close()
            return True
            
        except Exception as e:
            self.append_recv_signal.emit(f"发送ABORT指令失败: {e}")
            self.log(f"发送ABORT指令失败: {e}")
            return False

    def log(self, msg):
        timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        log_line = f"[{timestamp}] {msg}"
        self.log_signal.emit(log_line)
        # 写入本地日志文件
        if hasattr(self, 'log_file_path') and self.log_file_path:
            try:
                # 检查文件大小，如果超过50MB则轮转
                if os.path.exists(self.log_file_path):
                    file_size = os.path.getsize(self.log_file_path)
                    max_size = 50 * 1024 * 1024  # 50MB
                    if file_size > max_size:
                        self._rotate_log_file()
                
                with open(self.log_file_path, 'a', encoding='utf-8') as f:
                    f.write(log_line + '\n')
            except Exception as e:
                # 只提示一次，避免刷屏
                if not self._log_file_error:
                    self._log_file_error = True
                    self.append_recv_signal.emit(f"日志写入失败: {e}")

    def _rotate_log_file(self):
        """日志文件轮转"""
        try:
            # 生成新的日志文件名
            timestamp = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
            log_dir = os.path.dirname(self.log_file_path)
            new_log_filename = f"{timestamp}.log"
            new_log_path = os.path.join(log_dir, new_log_filename)
            
            # 重命名当前日志文件
            if os.path.exists(self.log_file_path):
                os.rename(self.log_file_path, new_log_path)
            
            # 更新日志文件路径
            self.log_file_path = new_log_path
            self.append_recv_signal.emit(f"日志文件已轮转，新文件: {new_log_filename}")
            
            # 清理旧日志文件（保留最近10个）
            self._cleanup_old_logs(log_dir)
            
        except Exception as e:
            self.append_recv_signal.emit(f"日志轮转失败: {e}")

    def _cleanup_old_logs(self, log_dir):
        """清理旧的日志文件，只保留最近10个"""
        try:
            log_files = []
            for file in os.listdir(log_dir):
                if file.endswith('.log'):
                    file_path = os.path.join(log_dir, file)
                    log_files.append((file_path, os.path.getmtime(file_path)))
            
            # 按修改时间排序，保留最新的10个
            log_files.sort(key=lambda x: x[1], reverse=True)
            for file_path, _ in log_files[10:]:
                try:
                    os.remove(file_path)
                except:
                    pass
        except Exception as e:
            self.append_recv_signal.emit(f"清理旧日志失败: {e}")

    def select_batch_file(self):
        file_path, _ = QFileDialog.getOpenFileName(self, "选择批量曝光指令文件", "", "Text Files (*.txt)")
        if file_path:
            self.batch_file_path.setText(file_path)
            self.batch_start_button.setEnabled(True)
        else:
            self.batch_file_path.clear()
            self.batch_start_button.setEnabled(False)

    def start_batch_exposure(self):
        file_path = self.batch_file_path.text().strip()
        if not file_path:
            self.append_recv_signal.emit("请先选择批量曝光指令文件！")
            return
        try:
            with open(file_path, 'r', encoding='utf-8') as f:
                lines = [line.strip() for line in f if line.strip()]
            if not lines:
                self.append_recv_signal.emit("文件内容为空！")
                return
            self.batch_commands = lines
            # 重置超时重拍计数器
            # 更新UI显示
            self.retry_count_display.setText("0")
            self.timeout_retry_count = 0
            self.batch_index = 0
            self.batch_running = True
            self.append_recv_signal.emit(f"开始批量曝光，共{len(lines)}条指令...")
            self.log(f"开始批量曝光，共{len(lines)}条指令...")
            
            # 启用中断按钮，禁用开始按钮
            self.interrupt_button.setEnabled(True)
            self.batch_start_button.setEnabled(False)
            
            # 创建后台线程执行批量曝光
            batch_thread = threading.Thread(
                target=self._batch_exposure_thread,
                daemon=True
            )
            batch_thread.start()
            
        except Exception as e:
            self.append_recv_signal.emit(f"读取文件失败: {e}")
            self.batch_running = False

    def _batch_exposure_thread(self):
        """后台线程执行批量曝光"""
        while self.batch_running and self.batch_index < len(self.batch_commands):
            # 获取当前指令
            cmd = self.batch_commands[self.batch_index]
            self.append_recv_signal.emit(f"执行第 {self.batch_index + 1}/{len(self.batch_commands)} 条指令: {cmd}")
            self.log(f"执行第 {self.batch_index + 1}/{len(self.batch_commands)} 条指令: {cmd}")
            
            # 发送指令并等待完成
            success = self._send_and_wait_response_thread(cmd, is_exposure=True)
            
            if success:
                # 指令执行完成，进入下一条
                self.batch_index += 1
                # 添加短暂延时，避免过于频繁的连接
                time.sleep(0.5)
            else:
                # 指令执行失败，检查是否需要重试
                if (self.timeout_retry_checkbox.isChecked() and 
                    self.current_exposure_start_time and self.current_exposure_timeout):
                    # 超时重拍功能启用，重试当前指令
                    # 增加超时重拍计数
                    self.timeout_retry_count += 1
                    # 更新UI显示
                    self.retry_count_display.setText(str(self.timeout_retry_count))
                    self.append_recv_signal.emit(f"第 {self.batch_index + 1} 条指令超时，将重试...（第{self.timeout_retry_count}次重拍）")
                    self.log(f"第 {self.batch_index + 1} 条指令超时，将重试...（第{self.timeout_retry_count}次重拍）")
                    # 不增加索引，重试当前指令
                    time.sleep(0.5)
                else:
                    # 普通失败，进入下一条
                    self.append_recv_signal.emit(f"第 {self.batch_index + 1} 条指令执行失败，继续下一条")
                    self.log(f"第 {self.batch_index + 1} 条指令执行失败，继续下一条")
                    self.batch_index += 1
                    time.sleep(0.5)
        
        # 批量完成
        self.append_recv_signal.emit(f"批量曝光完成！本次序列共执行了{self.timeout_retry_count}次超时重拍")
        self.log(f"批量曝光完成！本次序列共执行了{self.timeout_retry_count}次超时重拍")
        self.batch_running = False
        
        # 重置按钮状态
        self.interrupt_button.setEnabled(False)
        self.batch_start_button.setEnabled(True)

    def interrupt_batch_exposure(self):
        """中断当前批量曝光序列"""
        if self.batch_running:
            self.append_recv_signal.emit("正在中断批量曝光序列...")
            self.log("正在中断批量曝光序列...")
            
            # 断开当前TCP连接
            self.close_connection(None)  # 更新连接状态为未连接
            self.append_recv_signal.emit("已断开当前TCP连接")
            self.log("已断开当前TCP连接")
            
            # 重置所有批量曝光相关状态
            # 重置超时重拍计数器
            # 更新UI显示
            self.retry_count_display.setText("0")
            self.timeout_retry_count = 0
            self.batch_running = False
            self.batch_index = 0
            self.batch_commands = []
            
            # 重置按钮状态
            self.interrupt_button.setEnabled(False)
            self.batch_start_button.setEnabled(True)
            
            # 清除文件路径
            self.batch_file_path.clear()
            
            # 清除超时监控状态
            self.current_exposure_start_time = None
            self.current_exposure_timeout = None
            
            self.append_recv_signal.emit("批量曝光序列已中断，程序已回到初始状态。")
            self.log("批量曝光序列已中断，程序已回到初始状态。")
        else:
            self.append_recv_signal.emit("当前没有批量曝光序列在运行。")
            self.log("当前没有批量曝光序列在运行。")

    def select_log_dir(self):
        folder = QFileDialog.getExistingDirectory(self, "选择日志保存文件夹", "")
        if folder:
            self.log_dir_input.setText(folder)
            # 自动生成日志文件名
            log_filename = f"{self._log_file_timestamp}.log"
            self.log_file_path = os.path.join(folder, log_filename)
            self.append_recv_signal.emit(f"日志将保存到: {self.log_file_path}")
        else:
            # 恢复默认路径
            default_log_dir = os.path.join(os.getcwd(), 'logs')
            if not os.path.exists(default_log_dir):
                os.makedirs(default_log_dir)
            self.log_dir_input.setText(default_log_dir)
            log_filename = f"{self._log_file_timestamp}.log"
            self.log_file_path = os.path.join(default_log_dir, log_filename)
            self.append_recv_signal.emit(f"日志将保存到: {self.log_file_path}")

class UdpWidget(QWidget):
    log_signal = Signal(str)
    first_recv_signal = Signal(str)
    append_recv_signal = Signal(str)
    setplain_signal = Signal(str)

    def __init__(self):
        super().__init__()
        
        ip_label = QLabel("目标IP地址:")
        self.ip_input = QLineEdit()
        
        port_label = QLabel("目标端口号:")
        self.port_input = QLineEdit()
        
        local_port_label = QLabel("本地监听端口:")
        self.local_port_input = QLineEdit()
        self.local_port_input.setText("8001")  # 默认本地端口8001
        self.local_port_status = QLabel("当前监听端口: 8001")
        self.local_port_input.textChanged.connect(self.update_local_port_status)
        
        send_label = QLabel("发送内容:")
        self.send_input = QTextEdit()
        
        self.send_button = QPushButton("发送")
        self.send_button.clicked.connect(self.send_message)
        
        self.clear_button = QPushButton("清除返回信息")
        self.clear_button.clicked.connect(self.clear_recv_output)
        
        recv_label = QLabel("返回信息:")
        self.recv_output = QTextEdit()
        self.recv_output.setReadOnly(True)
        # layout
        ip_port_layout = QHBoxLayout()
        ip_port_layout.addWidget(ip_label)
        ip_port_layout.addWidget(self.ip_input)
        ip_port_layout.addWidget(port_label)
        ip_port_layout.addWidget(self.port_input)
        local_port_layout = QHBoxLayout()
        local_port_layout.addWidget(local_port_label)
        local_port_layout.addWidget(self.local_port_input)
        local_port_layout.addWidget(self.local_port_status)
        main_layout = QVBoxLayout()
        main_layout.addLayout(ip_port_layout)
        main_layout.addLayout(local_port_layout)
        main_layout.addWidget(send_label)
        main_layout.addWidget(self.send_input)
        main_layout.addWidget(self.send_button)
        main_layout.addWidget(self.clear_button)
        main_layout.addWidget(recv_label)
        main_layout.addWidget(self.recv_output)
        self.setLayout(main_layout)
        # 监听线程
        self.listen_thread = None
        self.listening_port = None
        self.update_local_port_status()  # 启动监听
        self.waiting_first_response = False
        self.first_recv_signal.connect(self.recv_output.setPlainText)
        self.append_recv_signal.connect(self.recv_output.append)
        self.setplain_signal.connect(self.recv_output.setPlainText)

    def update_local_port_status(self):
        port = self.local_port_input.text().strip()
        if port:
            self.local_port_status.setText(f"当前监听端口: {port}")
        else:
            self.local_port_status.setText("当前监听端口: 未设置")
        self.restart_listen_thread()

    def restart_listen_thread(self):
        port_text = self.local_port_input.text().strip()
        try:
            port = int(port_text)
            if port <= 0 or port > 65535:
                raise ValueError
        except Exception:
            port = None
        
        if port == self.listening_port and self.listen_thread and self.listen_thread.is_alive():
            return
        if self.listen_thread:
            self.stop_listen_thread()
        if port:
            import threading
            self.listening_port = port
            self.listen_thread_running = True
            self.listen_thread = threading.Thread(target=self.listen_loop, args=(port,), daemon=True)
            self.listen_thread.start()
        else:
            self.listening_port = None

    def stop_listen_thread(self):
        self.listen_thread_running = False
        import time
        time.sleep(0.1)
        self.listen_thread = None

    def listen_loop(self, port):
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
            s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            try:
                s.bind(("0.0.0.0", port))
            except Exception as e:
                self.append_recv_signal.emit(f"监听端口失败: {e}")
                return
            self.append_recv_signal.emit(f"已开始监听端口: {port}")
            while self.listen_thread_running:
                try:
                    s.settimeout(1)
                    data, addr = s.recvfrom(4096)
                    msg = data.decode(errors='ignore')
                    if self.waiting_first_response:
                        self.first_recv_signal.emit(f"{msg}")
                        self.waiting_first_response = False
                    self.log(f"收到: {msg}")
                    # 收到指定信息后的自动回复逻辑，包括temp_read、vaccu_read
                    if msg.strip() == 'temp_read':
                        arr = [random.uniform(-110, -80) for _ in range(12)]
                        arr += [random.uniform(60, 80) for _ in range(2)]
                        arr.append(random.uniform(-110, -80))
                        self.log(f"temp: {arr}")
                        fields = [
                            'CCD_3', 'CCD_2', 'Base_1', 'Absorbent_cold', 'Drive_circuit', 'Backup1',
                            'CCD_4', 'CCD_1', 'Base_2', 'Absorbent_hot', 'Dewar', 'Backup2',
                            'output', 'output_ab', 'temp_set'
                        ]
                        reply = '&&'.join([f"{name}:{value:.2f}" for name, value in zip(fields, arr)])
                        s.sendto(reply.encode(), addr)
                    elif msg.strip() == 'vaccu_read':
                        prefix = '0011074006'
                        # 前4位有效数字
                        middle_first = f"{random.randint(50, 2100):04d}"
                        # 后2位指数幂
                        middle_second = f"{random.randint(5, 18):02d}"
                        middle = middle_first + middle_second
                        suffix = '038'
                        vaccu_reply ='vaccu_read:' + prefix + middle + suffix
                        self.log(f"vaccu: {prefix + middle + suffix}")
                        s.sendto(vaccu_reply.encode(), addr)
                    else:
                        reply = '已收到信息'
                        # self.log(f"收到信息: {msg}")
                        s.sendto(reply.encode(), addr)
                except socket.timeout:
                    continue
                except Exception as e:
                    self.append_recv_signal.emit(f"监听异常: {e}")
                    self.log(f"监听异常: {e}")
                    break
            self.append_recv_signal.emit("监听已停止")

    def send_message(self):
        ip = self.ip_input.text().strip()
        port_text = self.port_input.text().strip()
        message = self.send_input.toPlainText()
        if not ip or not port_text or not message:
            self.setplain_signal.emit("请填写完整的目标IP、端口和发送内容！")
            return
        self.recv_output.clear()
        self.waiting_first_response = True
        try:
            port = int(port_text)
        except ValueError:
            self.setplain_signal.emit("请检查IP和端口号")
            return
        try:
            with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
                s.settimeout(2)  # UDP通信线程持续时间
                s.sendto(message.encode(), (ip, port))
                self.log(f"发送: {message}")
                try:
                    data, _ = s.recvfrom(4096)
                    recv_msg = data.decode(errors='ignore')
                    if self.waiting_first_response:
                        self.setplain_signal.emit(recv_msg)
                        self.waiting_first_response = False
                    # self.log(f"收到: {recv_msg}")
                except socket.timeout:
                    self.setplain_signal.emit("接收超时，无返回数据")
                    self.log("接收超时，无返回数据")
        except Exception as e:
            self.setplain_signal.emit(f"错误: {e}")
            self.log(f"错误: {e}")

    def clear_recv_output(self):
        self.recv_output.clear()

    def closeEvent(self, event):
        self.stop_listen_thread()
        event.accept()

    def log(self, msg):
        timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        self.log_signal.emit(f"[{timestamp}] {msg}")

class DualCameraWidget(QWidget):
    log_signal = Signal(str)
    append_recv_signal = Signal(str)

    def __init__(self):
        super().__init__()
        # Shared retry count
        self.shared_retry_count = 0
        self._running = False
        self._lock = threading.Lock()
        self._window_start = None
        self._window_end = None
        # 状态指示器（与标题同行展示）
        self.m_exec_status = QLabel("空闲")
        self.m_exec_status.setStyleSheet("color: red;")
        self.s_exec_status = QLabel("空闲")
        self.s_exec_status.setStyleSheet("color: red;")
        # 290 (master) controls
        self.master_title = QLabel("相机290（主序列）")
        self.m_ip_label = QLabel("目标IP地址:")
        self.m_ip_input = QLineEdit('192.168.0.233')
        self.m_ip_input.setFixedWidth(140)
        self.m_port_label = QLabel("目标端口号:")
        self.m_port_input = QLineEdit('9076')
        self.m_port_input.setFixedWidth(90)
        self.m_local_port_label = QLabel("本地端口:")
        self.m_local_port_input = QLineEdit('8080')
        self.m_local_port_input.setFixedWidth(90)
        self.m_timeout_wait_label = QLabel("超时等待(s):")
        self.m_timeout_wait_input = QLineEdit('20')
        self.m_timeout_wait_input.setFixedWidth(60)
        self.m_camid_label = QLabel("CamID:")
        self.m_camid_input = QLineEdit('25078')
        self.m_camid_input.setFixedWidth(90)
        self.m_batch_file_button = QPushButton("选择290批量文件")
        self.m_batch_file_button.setMinimumHeight(28)
        self.m_batch_file_button.clicked.connect(self._select_master_file)
        self.m_batch_file_path = QLineEdit()
        self.m_batch_file_path.setReadOnly(True)
        self.m_batch_file_path.setMinimumHeight(26)
        self.m_batch_file_path.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)

        # 4720 (slave) controls
        self.slave_title = QLabel("相机4720（副序列）")
        self.s_ip_label = QLabel("目标IP地址:")
        self.s_ip_input = QLineEdit('192.168.0.233')
        self.s_ip_input.setFixedWidth(140)
        self.s_port_label = QLabel("目标端口号:")
        self.s_port_input = QLineEdit('9086')
        self.s_port_input.setFixedWidth(90)
        self.s_local_port_label = QLabel("本地端口:")
        self.s_local_port_input = QLineEdit('8081')
        self.s_local_port_input.setFixedWidth(90)
        self.s_timeout_wait_label = QLabel("超时等待(s):")
        self.s_timeout_wait_input = QLineEdit('20')
        self.s_timeout_wait_input.setFixedWidth(60)
        self.s_camid_label = QLabel("CamID:")
        self.s_camid_input = QLineEdit('25078')
        self.s_camid_input.setFixedWidth(90)
        self.s_overhead_label = QLabel("4720预计读出用时(s):")
        self.s_overhead_input = QLineEdit('6')
        self.s_overhead_input.setFixedWidth(60)
        self.s_batch_file_button = QPushButton("选择4720批量文件")
        self.s_batch_file_button.setMinimumHeight(28)
        self.s_batch_file_button.clicked.connect(self._select_slave_file)
        self.s_batch_file_path = QLineEdit()
        self.s_batch_file_path.setReadOnly(True)
        self.s_batch_file_path.setMinimumHeight(26)
        self.s_batch_file_path.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)

        # Shared controls
        self.shared_retry_label = QLabel("重拍次数(共用):")
        self.shared_retry_display = QLineEdit('0')
        self.shared_retry_display.setReadOnly(True)
        self.shared_retry_display.setFixedWidth(80)
        # Mode selector and overlap delay
        self.mode_label = QLabel("模式:")
        self.mode_select = QComboBox()
        self.mode_select.addItems(["不同时读出", "允许同时读出"])  # default first
        self.delay_label = QLabel("触发延迟(s):")
        self.delay_input = QLineEdit('3')
        self.delay_input.setFixedWidth(60)
        # Mode change toggles delay visibility
        self.mode_select.currentTextChanged.connect(self._on_mode_changed)
        self.start_button = QPushButton("开始组合批量")
        self.start_button.setMinimumHeight(32)
        self.start_button.setEnabled(False)
        self.start_button.clicked.connect(self._start_combined_batch)
        self.stop_button = QPushButton("中断全部")
        self.stop_button.setMinimumHeight(32)
        self.stop_button.setEnabled(False)
        self.stop_button.clicked.connect(self._interrupt_all)

        # Master group layout (grid)
        master_box = QGroupBox("相机290序列")
        master_grid = QGridLayout()
        master_grid.setContentsMargins(8, 8, 8, 8)
        master_grid.setHorizontalSpacing(8)
        master_grid.setVerticalSpacing(6)
        row = 0
        # 首行参数（不放状态在此行）
        self.m_exec_status.setFixedHeight(18)
        master_grid.addWidget(self.m_ip_label, row, 0)
        master_grid.addWidget(self.m_ip_input, row, 1)
        master_grid.addWidget(self.m_port_label, row, 2)
        master_grid.addWidget(self.m_port_input, row, 3)
        master_grid.addWidget(self.m_local_port_label, row, 4)
        master_grid.addWidget(self.m_local_port_input, row, 5)
        row += 1
        master_grid.addWidget(self.m_timeout_wait_label, row, 0)
        master_grid.addWidget(self.m_timeout_wait_input, row, 1)
        # Hide CamID fields from UI (fixed as 25078)
        self.m_camid_label.hide()
        self.m_camid_input.hide()
        # 状态放在第二行最后
        master_grid.addWidget(self.m_exec_status, row, 6)
        row += 1
        master_grid.addWidget(self.m_batch_file_button, row, 0)
        master_grid.addWidget(self.m_batch_file_path, row, 1, 1, 6)
        master_box.setLayout(master_grid)

        # Slave group layout (grid)
        slave_box = QGroupBox("相机4720序列")
        slave_grid = QGridLayout()
        slave_grid.setContentsMargins(8, 8, 8, 8)
        slave_grid.setHorizontalSpacing(8)
        slave_grid.setVerticalSpacing(6)
        row = 0
        # 首行参数（不放状态在此行）
        self.s_exec_status.setFixedHeight(18)
        slave_grid.addWidget(self.s_ip_label, row, 0)
        slave_grid.addWidget(self.s_ip_input, row, 1)
        slave_grid.addWidget(self.s_port_label, row, 2)
        slave_grid.addWidget(self.s_port_input, row, 3)
        slave_grid.addWidget(self.s_local_port_label, row, 4)
        slave_grid.addWidget(self.s_local_port_input, row, 5)
        row += 1
        slave_grid.addWidget(self.s_timeout_wait_label, row, 0)
        slave_grid.addWidget(self.s_timeout_wait_input, row, 1)
        # Hide CamID fields from UI (fixed as 25078)
        self.s_camid_label.hide()
        self.s_camid_input.hide()
        slave_grid.addWidget(self.s_overhead_label, row, 4)
        slave_grid.addWidget(self.s_overhead_input, row, 5)
        # 状态放在第二行最后
        slave_grid.addWidget(self.s_exec_status, row, 6)
        row += 1
        slave_grid.addWidget(self.s_batch_file_button, row, 0)
        slave_grid.addWidget(self.s_batch_file_path, row, 1, 1, 6)
        slave_box.setLayout(slave_grid)

        # Shared row
        shared_row = QHBoxLayout()
        shared_row.setContentsMargins(8, 0, 8, 0)
        shared_row.addWidget(self.shared_retry_label)
        shared_row.addWidget(self.shared_retry_display)
        shared_row.addSpacing(12)
        shared_row.addWidget(self.mode_label)
        shared_row.addWidget(self.mode_select)
        shared_row.addSpacing(12)
        shared_row.addWidget(self.delay_label)
        shared_row.addWidget(self.delay_input)
        shared_row.addStretch(1)
        shared_row.addWidget(self.start_button)
        shared_row.addWidget(self.stop_button)

        # Stack vertically: 290 on top, 4720 below
        main_layout = QVBoxLayout()
        main_layout.setContentsMargins(10, 10, 10, 10)
        main_layout.setSpacing(10)
        main_layout.addWidget(master_box)
        main_layout.addWidget(slave_box)
        main_layout.addLayout(shared_row)
        self.setLayout(main_layout)

        # Signals to enable Start button only when both files selected
        self.m_batch_file_path.textChanged.connect(self._update_start_enabled)
        self.s_batch_file_path.textChanged.connect(self._update_start_enabled)
        # Initialize mode-dependent visibility
        self._on_mode_changed(self.mode_select.currentText())

        # Runtime
        self.master_thread = None
        self.slave_thread = None
        self.master_commands = []
        self.slave_commands = []
        self.master_index = 0
        self.slave_index = 0
        self.slave_active_socket = None
        self.slave_current_exposure_started_at = None
        self.slave_current_timeout_deadline = None
        # overlap mode trigger queue (single pending)
        self._pending_trigger = False
        # overlap mode delayed trigger scheduler flag
        self._trigger_scheduled = False
        # master sequence completion flag
        self._master_completed = False

    def _update_start_enabled(self):
        can_start = bool(self.m_batch_file_path.text().strip()) and bool(self.s_batch_file_path.text().strip())
        self.start_button.setEnabled(can_start and not self._running)

    def _select_master_file(self):
        file_path, _ = QFileDialog.getOpenFileName(self, "选择290批量曝光指令文件", "", "Text Files (*.txt)")
        if file_path:
            self.m_batch_file_path.setText(file_path)
        else:
            self.m_batch_file_path.clear()

    def _select_slave_file(self):
        file_path, _ = QFileDialog.getOpenFileName(self, "选择4720批量曝光指令文件", "", "Text Files (*.txt)")
        if file_path:
            self.s_batch_file_path.setText(file_path)
        else:
            self.s_batch_file_path.clear()

    def _start_combined_batch(self):
        if self._running:
            return
        m_path = self.m_batch_file_path.text().strip()
        s_path = self.s_batch_file_path.text().strip()
        if not m_path or not s_path:
            self._append_recv("请先选择两台相机的批量文件！")
            return
        try:
            with open(m_path, 'r', encoding='utf-8') as f:
                self.master_commands = [line.strip() for line in f if line.strip()]
            with open(s_path, 'r', encoding='utf-8') as f:
                self.slave_commands = [line.strip() for line in f if line.strip()]
        except Exception as e:
            self._append_recv(f"读取批量文件失败: {e}")
            return
        if not self.master_commands or not self.slave_commands:
            self._append_recv("文件内容为空！两台相机的序列都必须存在")
            return
        self._running = True
        self.start_button.setEnabled(False)
        self.stop_button.setEnabled(True)
        self.master_index = 0
        self.slave_index = 0
        self.shared_retry_count = 0
        self.shared_retry_display.setText('0')
        self._master_completed = False
        # 重置指示灯
        self._set_master_executing(False)
        self._set_slave_executing(False)
        self._append_recv("开始组合批量（不允许同时读出）...")
        self._log("开始组合批量（不允许同时读出）...")
        if self.mode_select.currentText() == "允许同时读出":
            self._append_recv("模式：允许同时读出")
            self._log("模式：允许同时读出")
            self._pending_trigger = False
            self._trigger_scheduled = False
            self.master_thread = threading.Thread(target=self._master_loop_overlap, daemon=True)
            self.slave_thread = threading.Thread(target=self._slave_loop_overlap, daemon=True)
        else:
            self._append_recv("模式：不同时读出")
            self._log("模式：不同时读出")
            self.master_thread = threading.Thread(target=self._master_loop, daemon=True)
            self.slave_thread = threading.Thread(target=self._slave_loop, daemon=True)
        self.master_thread.start()
        self.slave_thread.start()

    def _interrupt_all(self):
        if not self._running:
            return
        self._append_recv("正在中断全部...")
        self._log("正在中断全部...")
        self._running = False
        # Try to abort slave if running
        try:
            self._slave_send_abort()
        except Exception:
            pass
        self.stop_button.setEnabled(False)
        self.start_button.setEnabled(True)
        self._window_start = None
        self._window_end = None
        self._master_completed = False
        # 熄灭指示灯
        self._set_master_executing(False)
        self._set_slave_executing(False)
        # 清除文件路径
        self.batch_file_path.clear()
        
        # 清除超时监控状态
        self.current_exposure_start_time = None
        self.current_exposure_timeout = None
        
        self.append_recv_signal.emit("批量曝光序列已中断，程序已回到初始状态。")
        self.log("批量曝光序列已中断，程序已回到初始状态。")

    def _append_recv(self, msg: str):
        self.append_recv_signal.emit(msg)

    def _log(self, msg: str):
        timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        self.log_signal.emit(f"[{timestamp}] {msg}")

    # --- Networking helpers ---
    def _create_connection(self, ip: str, port: int, local_port: int):
        try:
            tcp_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            tcp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            tcp_socket.bind(("0.0.0.0", local_port))
            tcp_socket.connect((ip, port))
            return tcp_socket
        except Exception as e:
            self._append_recv(f"连接失败: {e}")
            return None

    def _close_connection(self, tcp_socket):
        if tcp_socket:
            try:
                tcp_socket.shutdown(socket.SHUT_RDWR)
            except:
                pass
            try:
                tcp_socket.close()
            except:
                pass

    def _parse_exposure_time(self, command: str) -> float:
        try:
            match = re.search(r'EXPST_TIME=(\d+\.?\d*)', command, re.IGNORECASE)
            if match:
                return float(match.group(1))
            return 10.0
        except:
            return 10.0

    def _send_abort(self, ip: str, port: int, abort_id: int, cam_id: str) -> bool:
        try:
            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            s.settimeout(5)
            s.connect((ip, port))
            abort_message = f"<ID={abort_id}><CamID={cam_id}>"
            s.sendall(abort_message.encode())
            try:
                data = s.recv(4096)
                if data:
                    self._append_recv(f"ABORT响应: {data.decode(errors='ignore')}")
            except socket.timeout:
                self._append_recv("ABORT指令发送成功，但未收到响应")
            s.close()
            return True
        except Exception as e:
            self._append_recv(f"发送ABORT指令失败: {e}")
            return False

    # Convenience wrappers for master/slave
    def _master_send_abort(self):
        ip = self.m_ip_input.text().strip()
        port = int(self.m_port_input.text().strip())
        camid = '25078'
        self._append_recv("[290] 发送ABORT...")
        self._log("[290] 发送ABORT...")
        ok = self._send_abort(ip, port, 303, camid)
        if ok:
            self._append_recv("[290] ABORT发送完成")
        return ok

    def _slave_send_abort(self):
        ip = self.s_ip_input.text().strip()
        port = int(self.s_port_input.text().strip())
        camid = '25078'
        self._append_recv("[4720] 发送ABORT...")
        self._log("[4720] 发送ABORT...")
        ok = self._send_abort(ip, port, 603, camid)
        if ok:
            self._append_recv("[4720] ABORT发送完成")
        return ok

    # --- Threads ---
    def _master_loop(self):
        while self._running and self.master_index < len(self.master_commands):
            cmd = self.master_commands[self.master_index]
            exp = self._parse_exposure_time(cmd)
            # send master command
            ip = self.m_ip_input.text().strip()
            port = int(self.m_port_input.text().strip())
            local_port = int(self.m_local_port_input.text().strip())
            try:
                timeout_wait = float(self.m_timeout_wait_input.text().strip())
            except ValueError:
                timeout_wait = 20.0
            s = self._create_connection(ip, port, local_port)
            if not s:
                # treat as failure and continue to next
                self.master_index += 1
                continue
            try:
                s.sendall(cmd.encode())
                self._set_master_executing(True)
                now = time.time()
                with self._lock:
                    self._window_start = now
                    self._window_end = now + exp
                self._append_recv(f"[290] 发送指令: {cmd}")
                self._log(f"[290] 发送指令: {cmd}")
                # monitor response
                start_time = now
                exposure_timeout = exp + timeout_wait
                had_timeout = False
                while self._running:
                    # window is independent from completion; here we just wait for DONE or timeout
                    s.settimeout(1.0)
                    try:
                        data = s.recv(4096)
                        if not data:
                            break
                        msg = data.decode(errors='ignore')
                        self._append_recv(f"[290] 收到: {msg}")
                        self._log(f"[290] 收到: {msg}")
                        if "<ACTION=DONE>" in msg:
                            self._append_recv("[290] 曝光完成")
                            self._log("[290] 曝光完成")
                            break
                    except socket.timeout:
                        pass
                    if time.time() - start_time > exposure_timeout:
                        had_timeout = True
                        self._append_recv(f"[290] 曝光超时，超过 {exposure_timeout:.1f}s")
                        self._log(f"[290] 曝光超时，超过 {exposure_timeout:.1f}s")
                        # abort and mark retry
                        self._master_send_abort()
                        with self._lock:
                            self.shared_retry_count += 1
                        self.shared_retry_display.setText(str(self.shared_retry_count))
                        time.sleep(2)
                        # retry same command -> continue outer while without increment index
                        break
                if had_timeout:
                    self._set_master_executing(False)
                    continue
                # success, advance
                self.master_index += 1
            except Exception as e:
                self._append_recv(f"[290] 通信异常: {e}")
                self._log(f"[290] 通信异常: {e}")
                self.master_index += 1
            finally:
                self._set_master_executing(False)
                self._close_connection(s)
        # finish
        self._append_recv("[290] 主序列完成")
        self._log("[290] 主序列完成")
        # No longer provide window
        with self._lock:
            self._window_start = None
            self._window_end = None
        self._running = False
        self.stop_button.setEnabled(False)
        self.start_button.setEnabled(True)
        # ... existing code ...

    def _slave_loop(self):
        while self._running and self.slave_index < len(self.slave_commands):
            # wait for a window
            with self._lock:
                window_end = self._window_end
            if not (window_end and self._window_start):
                time.sleep(0.05)
                continue
            # check if we are currently executing; if not, try schedule next
            cmd = self.slave_commands[self.slave_index]
            exp = self._parse_exposure_time(cmd)
            try:
                overhead = float(self.s_overhead_input.text().strip())
            except ValueError:
                overhead = 6.0
            est = exp + overhead
            now = time.time()
            # if can fit entirely into current window starting now
            if now + est <= window_end:
                # start this command
                ip = self.s_ip_input.text().strip()
                port = int(self.s_port_input.text().strip())
                local_port = int(self.s_local_port_input.text().strip())
                try:
                    timeout_wait = float(self.s_timeout_wait_input.text().strip())
                except ValueError:
                    timeout_wait = 20.0
                s = self._create_connection(ip, port, local_port)
                if not s:
                    # treat as failure, move to next?
                    # keep same index to retry next window
                    time.sleep(0.1)
                    continue
                self.slave_active_socket = s
                try:
                    s.sendall(cmd.encode())
                    self._set_slave_executing(True)
                    self._append_recv(f"[4720] 发送指令: {cmd}")
                    self._log(f"[4720] 发送指令: {cmd}")
                    self.slave_current_exposure_started_at = time.time()
                    exposure_timeout = exp + timeout_wait
                    forced_aborted = False
                    had_timeout = False
                    while self._running:
                        # Force ABORT at window end
                        with self._lock:
                            w_end = self._window_end
                        if w_end and time.time() >= w_end:
                            # window ended while still running
                            self._append_recv("[4720] 窗口结束，强制ABORT")
                            self._log("[4720] 窗口结束，强制ABORT")
                            self._slave_send_abort()
                            with self._lock:
                                self.shared_retry_count += 1
                            self.shared_retry_display.setText(str(self.shared_retry_count))
                            forced_aborted = True
                            time.sleep(2)
                            break
                        s.settimeout(0.5)
                        try:
                            data = s.recv(4096)
                            if not data:
                                break
                            msg = data.decode(errors='ignore')
                            self._append_recv(f"[4720] 收到: {msg}")
                            self._log(f"[4720] 收到: {msg}")
                            if "<ACTION=DONE>" in msg:
                                self._append_recv("[4720] 曝光完成")
                                self._log("[4720] 曝光完成")
                                break
                        except socket.timeout:
                            pass
                        if time.time() - self.slave_current_exposure_started_at > exposure_timeout:
                            had_timeout = True
                            self._append_recv(f"[4720] 曝光超时，超过 {exposure_timeout:.1f}s")
                            self._log(f"[4720] 曝光超时，超过 {exposure_timeout:.1f}s")
                            self._slave_send_abort()
                            with self._lock:
                                self.shared_retry_count += 1
                            self.shared_retry_display.setText(str(self.shared_retry_count))
                            time.sleep(2)
                            break
                    # decide next index
                    if forced_aborted or had_timeout:
                        # retry same command in next window
                        pass
                    else:
                        # success advance
                        self.slave_index += 1
                except Exception as e:
                    self._append_recv(f"[4720] 通信异常: {e}")
                    self._log(f"[4720] 通信异常: {e}")
                    # retry next window
                finally:
                    self._set_slave_executing(False)
                    self._close_connection(self.slave_active_socket)
                    self.slave_active_socket = None
                    self.slave_current_exposure_started_at = None
                    self.slave_current_timeout_deadline = None
            else:
                # cannot fit; wait for next window
                time.sleep(0.05)
                continue
        self._append_recv("[4720] 副序列完成或已停止")
        self._log("[4720] 副序列完成或已停止")
        # ... existing code ...

    # --- Overlap-allowed mode ---
    def _on_mode_changed(self, text: str):
        allow = (text == "允许同时读出")
        self.delay_label.setVisible(allow)
        self.delay_input.setVisible(allow)
        # Overhead only for non-overlap (no simultaneous readout)
        self.s_overhead_label.setVisible(not allow)
        self.s_overhead_input.setVisible(not allow)

    def _master_loop_overlap(self):
        while self._running and self.master_index < len(self.master_commands):
            cmd = self.master_commands[self.master_index]
            exp = self._parse_exposure_time(cmd)
            ip = self.m_ip_input.text().strip()
            port = int(self.m_port_input.text().strip())
            local_port = int(self.m_local_port_input.text().strip())
            try:
                timeout_wait = float(self.m_timeout_wait_input.text().strip())
            except ValueError:
                timeout_wait = 20.0
            s = self._create_connection(ip, port, local_port)
            if not s:
                self.master_index += 1
                continue
            try:
                s.sendall(cmd.encode())
                self._set_master_executing(True)
                now = time.time()
                self._append_recv(f"[290] 发送指令: {cmd}")
                self._log(f"[290] 发送指令: {cmd}")
                # trigger rule: if contains <EXP_TYPE=bs>, allow single pending trigger
                if "<EXP_TYPE=bs>" in cmd:
                    # schedule one delayed trigger if none pending or scheduled
                    with self._lock:
                        if not self._pending_trigger and not self._trigger_scheduled:
                            try:
                                delay_s = float(self.delay_input.text().strip())
                            except ValueError:
                                delay_s = 3.0
                            self._trigger_scheduled = True
                            threading.Thread(target=self._delayed_slave_trigger, args=(delay_s,), daemon=True).start()
                # wait for DONE or timeout
                start_time = now
                exposure_timeout = exp + timeout_wait
                had_timeout = False
                while self._running:
                    s.settimeout(1.0)
                    try:
                        data = s.recv(4096)
                        if not data:
                            break
                        msg = data.decode(errors='ignore')
                        self._append_recv(f"[290] 收到: {msg}")
                        self._log(f"[290] 收到: {msg}")
                        if "<ACTION=DONE>" in msg:
                            self._append_recv("[290] 曝光完成")
                            self._log("[290] 曝光完成")
                            break
                    except socket.timeout:
                        pass
                    if time.time() - start_time > exposure_timeout:
                        had_timeout = True
                        self._append_recv(f"[290] 曝光超时，超过 {exposure_timeout:.1f}s")
                        self._log(f"[290] 曝光超时，超过 {exposure_timeout:.1f}s")
                        self._master_send_abort()
                        with self._lock:
                            self.shared_retry_count += 1
                        self.shared_retry_display.setText(str(self.shared_retry_count))
                        time.sleep(2)
                        break
                if had_timeout:
                    self._set_master_executing(False)
                    continue
                self.master_index += 1
            except Exception as e:
                self._append_recv(f"[290] 通信异常: {e}")
                self._log(f"[290] 通信异常: {e}")
                self.master_index += 1
            finally:
                self._set_master_executing(False)
                self._close_connection(s)
        self._append_recv("[290] 主序列完成")
        self._log("[290] 主序列完成")
        self._running = False
        self.stop_button.setEnabled(False)
        self.start_button.setEnabled(True)
        # ... existing code ...

    def _slave_loop_overlap(self):
        while self._running:
            # If slave finished all commands, we can exit its loop but keep master running
            if self.slave_index >= len(self.slave_commands):
                time.sleep(0.1)
                if not self._running:
                    break
                continue
            # wait for pending trigger
            consume = False
            with self._lock:
                if self._pending_trigger:
                    consume = True
                    self._pending_trigger = False
            if not consume:
                time.sleep(0.02)
                continue
            # send one 4720 command if idle
            cmd = self.slave_commands[self.slave_index]
            exp = self._parse_exposure_time(cmd)
            ip = self.s_ip_input.text().strip()
            port = int(self.s_port_input.text().strip())
            local_port = int(self.s_local_port_input.text().strip())
            try:
                timeout_wait = float(self.s_timeout_wait_input.text().strip())
            except ValueError:
                timeout_wait = 20.0
            s = self._create_connection(ip, port, local_port)
            if not s:
                time.sleep(0.1)
                continue
            try:
                s.sendall(cmd.encode())
                self._set_slave_executing(True)
                self._append_recv(f"[4720] 发送指令: {cmd}")
                self._log(f"[4720] 发送指令: {cmd}")
                start = time.time()
                exposure_timeout = exp + timeout_wait
                had_timeout = False
                while self._running:
                    s.settimeout(0.5)
                    try:
                        data = s.recv(4096)
                        if not data:
                            break
                        msg = data.decode(errors='ignore')
                        self._append_recv(f"[4720] 收到: {msg}")
                        self._log(f"[4720] 收到: {msg}")
                        if "<ACTION=DONE>" in msg:
                            self._append_recv("[4720] 曝光完成")
                            self._log("[4720] 曝光完成")
                            break
                    except socket.timeout:
                        pass
                    if time.time() - start > exposure_timeout:
                        had_timeout = True
                        self._append_recv(f"[4720] 曝光超时，超过 {exposure_timeout:.1f}s")
                        self._log(f"[4720] 曝光超时，超过 {exposure_timeout:.1f}s")
                        self._slave_send_abort()
                        with self._lock:
                            self.shared_retry_count += 1
                        self.shared_retry_display.setText(str(self.shared_retry_count))
                        time.sleep(2)
                        break
                if not had_timeout:
                    self.slave_index += 1
            except Exception as e:
                self._append_recv(f"[4720] 通信异常: {e}")
                self._log(f"[4720] 通信异常: {e}")
            finally:
                self._set_slave_executing(False)
                self._close_connection(s)
        self._append_recv("[4720] 副序列完成或已停止")
        self._log("[4720] 副序列完成或已停止")

    def _set_master_executing(self, executing: bool):
        self.m_exec_status.setText("执行中" if executing else "空闲")
        self.m_exec_status.setStyleSheet("color: green;" if executing else "color: red;")

    def _set_slave_executing(self, executing: bool):
        self.s_exec_status.setText("执行中" if executing else "空闲")
        self.s_exec_status.setStyleSheet("color: green;" if executing else "color: red;")

    def _delayed_slave_trigger(self, delay_s: float):
        time.sleep(max(0.0, delay_s))
        with self._lock:
            if self._running:
                # 延迟到达时才设置触发，确保真正延迟发送
                self._pending_trigger = True
            # 清除调度标记（无论是否仍在运行）
            self._trigger_scheduled = False

    def eventFilter(self, obj, event):
        try:
            from PySide6.QtCore import QEvent
            if event.type() in (QEvent.Resize, QEvent.Show):
                self._reposition_status_dots()
        except Exception:
            pass
        return super().eventFilter(obj, event)

    def _reposition_status_dots(self):
        # 将圆点贴紧到GroupBox左上角标题附近
        # 通过少量偏移避免遮挡边框与标题文字
        if getattr(self, '_master_box', None) and self.m_exec_status:
            self.m_exec_status.move(6, 10)
            self.m_exec_status.raise_()
        if getattr(self, '_slave_box', None) and self.s_exec_status:
            self.s_exec_status.move(6, 10)
            self.s_exec_status.raise_()

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("ICS测试工具")
        self.resize(1100, 600)
        
        # 设置窗口图标
        try:
            if os.path.exists('logo.png'):
                icon = QIcon('logo.png')
                self.setWindowIcon(icon)
                print("窗口图标设置成功")
            else:
                print("logo.png文件不存在")
        except Exception as e:
            print(f"设置窗口图标失败: {e}")
        
        self.tabs = QTabWidget()
        self.udp_widget = UdpWidget()
        self.tcp_widget = TcpWidget(self.udp_widget)
        self.dual_widget = DualCameraWidget()
        self.tabs.addTab(self.tcp_widget, "OCS模拟(TCP)")
        self.tabs.addTab(self.udp_widget, "温控板模拟(UDP)")
        self.tabs.addTab(self.dual_widget, "290/4720协同")
        # 日志窗口
        self.log_output = QTextEdit()
        self.log_output.setReadOnly(True)
        self.log_output.setMinimumWidth(350)
        self.clear_log_button = QPushButton("清除日志")
        self.clear_log_button.setMinimumHeight(32)
        self.clear_log_button.clicked.connect(self.clear_log_output)

        right_panel = QWidget()
        right_layout = QVBoxLayout()
        right_layout.addWidget(self.clear_log_button)
        right_layout.addWidget(self.log_output)
        right_panel.setLayout(right_layout)

        splitter = QSplitter(Qt.Horizontal)
        splitter.addWidget(self.tabs)
        splitter.addWidget(right_panel)
        splitter.setSizes([600, 600])  # 让日志区和左侧tab页一样宽
        self.setCentralWidget(splitter)
        # 信号连接
        self.tcp_widget.log_signal.connect(self.append_log)
        self.udp_widget.log_signal.connect(self.append_log)
        self.dual_widget.log_signal.connect(self.append_log)
        # 将双相机widget的用户提示也输出到左侧TCP的返回框更便于查看
        self.dual_widget.append_recv_signal.connect(self.tcp_widget.append_recv_signal)
        
    def append_log(self, msg):
        self.log_output.append(msg)

    def clear_log_output(self):
        self.log_output.clear()

if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec())
